Handle unknown event types gracefully instead of crashing#680
Handle unknown event types gracefully instead of crashing#680Alan4506 wants to merge 3 commits intosmithy-lang:developfrom
Conversation
| logger.debug( | ||
| "Unknown event type: %s", member_name | ||
| ) | ||
| from smithy_core.shapes import ShapeID |
There was a problem hiding this comment.
Should we move the ShapeID import to the top with the other smithy_core.shapes imports? Doesn't seem like we need it inline here.
There was a problem hiding this comment.
We should add unit tests in test_deserializers.py for the unknown event handling path.
Also, the test fixture EventStreamDeserializer._consumer still raises SmithyError in the default case. Should that be updated to match the new behavior?
| result = self._deserializer(deserializer) | ||
| logger.debug("Successfully deserialized event: %s", result) | ||
| if isinstance(getattr(result, "value"), Exception): | ||
| if isinstance(getattr(result, "value", None), Exception): |
There was a problem hiding this comment.
| if isinstance(getattr(result, "value", None), Exception): | |
| value = getattr(result, "value", None) | |
| if isinstance(value, Exception): |
Small readability suggestion (non-blocking): since only some variants have value, maybe assign it first and check that instead so it's a bit clearer and avoids looking up value twice here and in the raise statement.
| message = EventMessage( | ||
| headers={ | ||
| ":message-type": "event", | ||
| ":event-type": "intermediateGroupEvent", |
There was a problem hiding this comment.
| ":event-type": "intermediateGroupEvent", | |
| ":event-type": "unmodeledEvent", |
| deserializer = EventDeserializer(event=source, payload_codec=JSONCodec()) | ||
| result = EventStreamDeserializer().deserialize(deserializer) | ||
| assert isinstance(result, EventStreamUnknown) | ||
| assert result.tag == "intermediateGroupEvent" |
There was a problem hiding this comment.
| assert result.tag == "intermediateGroupEvent" | |
| assert result.tag == "unmodeledEvent" |
| EventStreamDeserializer, | ||
| EventStreamErrorEvent, | ||
| EventStreamOperationInputOutput, | ||
| EventStreamUnknown, |
There was a problem hiding this comment.
| EventStreamUnknown, | |
| EventStreamUnknownEvent, |
There was a problem hiding this comment.
I did not add "Event" at the end because the generated code does not have "Event" suffix after "Unknown".
| assert source is not None | ||
| deserializer = EventDeserializer(event=source, payload_codec=JSONCodec()) | ||
| result = EventStreamDeserializer().deserialize(deserializer) | ||
| assert isinstance(result, EventStreamUnknown) |
There was a problem hiding this comment.
| assert isinstance(result, EventStreamUnknown) | |
| assert isinstance(result, EventStreamUnknownEvent) |
There was a problem hiding this comment.
I did not add "Event" at the end because the generated code does not have "Event" suffix after "Unknown".
|
|
||
|
|
||
| UNKNOWN_EVENT_CASE = ( | ||
| EventStreamUnknown(tag="intermediateGroupEvent"), |
There was a problem hiding this comment.
nit - this event type is specific to QBusiness, we should use something generic:
| EventStreamUnknown(tag="intermediateGroupEvent"), | |
| EventStreamUnknownEvent(tag="unmodeledEvent"), |
| | EventStreamBlobPayloadEvent | ||
| | EventStreamErrorEvent | ||
| | EventStreamUnknownEvent | ||
| | EventStreamUnknown |
There was a problem hiding this comment.
| | EventStreamUnknown | |
| | EventStreamUnknownEvent |
There was a problem hiding this comment.
I modified this because the generated code does not have "Event" suffix after "Unknown".
| ${4C|} | ||
| case _: | ||
| logger.debug("Unexpected member schema: %s", schema) | ||
| self._set_result($5L(tag=schema.member_name or "")) |
There was a problem hiding this comment.
Why use tag=schema.member_name or "" here instead of something like tag=schema.expect_member_name()?
There was a problem hiding this comment.
We should remove this TODO
| member_schema, headers | ||
| ) | ||
| consumer(member_schema, message_deserializer) | ||
| member_schema = schema.members.get(member_name) |
There was a problem hiding this comment.
I'm thinking we can consolidate this logic into a member_schema = self._resolve_member_schema(schema, member_name) method to make this a bit cleaner.
| # member_name and a member_index of -1 so the | ||
| # generated default branch constructs the unknown | ||
| # variant with the correct tag. | ||
| logger.debug("Unknown event type: %s", member_name) |
There was a problem hiding this comment.
This logic seems odd, why can't we do something like:
def _resolve_member_schema(self, schema: Schema, member_name: str) ->
Schema:
if member_schema := schema.members.get(member_name):
return member_schema
logger.debug(
"Received unmodeled event stream member %s for union %s",
member_name,
schema.id,
)
return Schema.member(
id=schema.id.with_member(member_name),
target=schema,
index=-1,
)This keeps the real event payload/headers intact and lets the generated union fallback produce *Unknown(tag=...) in one place, instead of special-casing unknown events in the runtime with a fake {} document.
Background
The Smithy spec (streaming.html#client-behavior) states:
Currently, in
smithy-aws-event-streamdeserializers.py,EventDeserializer.read_struct()does a hard dictionary lookup (schema.members[member_name]) which raisesKeyErrorwhen the service sends an event type not present in the generated model. So we have to add some logic to handle unknown event types.Additionally, although smithy-python automatically creates unknown variants for streams, these variants only have a
tagfield unlike normal events that have avaluefield. WhenAWSEventReceiver.receive()callsgetattr(result, "value")without a default, it raisesAttributeErrorfor unknown variants.These were discovered during Q Business Chat streaming integration testing. The service sends
intermediateGroupEventandintermediateMessageEventevents not in any published SDK model, causing theasync for event in output_streamloop to crash.Reference implementations in other Smithy SDKs:
Both smithy-java and smithy-rs (Rust) already handle unknown event types gracefully:
smithy-java (
TestEventStream.java): Generates a$Unknown(String memberName)record for each event stream union. The builder has an$unknownMember(String memberName)method that the deserializer calls when it encounters an unrecognized event type. The unknown variant preserves the event type name.smithy-rs (
EventStreamUnmarshallerGenerator.kt): The generated unmarshaller has an_unknown_variantmatch arm that returnsOk(UnmarshalledMessage::Event(Output::Unknown))for client targets. Unlike Java, the Rust unknown variant does not preserve the event type name.This PR follows the smithy-java approach of preserving the event type name in the unknown variant's
tagfield, so users can identify which unmodeled event was received (e.g.,ChatOutputStreamUnknown(tag="intermediateGroupEvent")).Description of Changes
deserializers.py): Whenmember_nameis not inschema.members, construct a syntheticSchemawithmember_index=-1and call the consumer, which triggers the generatedcase _:branch inUnionGenerator.java.UnionGenerator.java): Thecase _:branch (previously just logging) now also constructs the per-union unknown variant with the event type name astag.aio/__init__.py): Changegetattr(result, "value")togetattr(result, "value", None)to preventAttributeErroron unknown variants.Testing
Regenerated all existing clients locally and all their integration tests passed. Also, verified with Q Business Chat streaming — unknown events (
intermediateGroupEvent,intermediateMessageEvent) are now handled gracefully instead of crashing. Users can identify unknown events viaisinstanceand access the event type name:By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.